Airbnb 的 React Native 经验:技术 (译)

作者:Gabriel Peal
翻译:Rebecca Han

技术方面的细节

15298974917220

这是 Airbnb 对外输出使用 React Native 的经验以及下一步在移动端做些什么的系列博文第二篇。

React Native 在 Android,iOS,web 及跨平台框架中,本身算是比较新且发展迅速的平台。在其发展的两年后,我们可以肯定的说,React Native 在很多方面是革命性的。它算得上是移动领域一个范例式的转变,我们能从它所达到的许多目标中获益。然而React Native 所带来的好处还伴随着一系列的痛点。

优点

跨平台

React Native 带来的最主要的好处就是你写的代码可以以原生的形式跑在 Android 和 iOS平台上。大多数 React Native 特性能够实现95%-100%的共享代码,而0.2%的文件是有平台特殊性的(.android.js/**.ios.js)。

统一的设计语言系统(DLS)

我们开发了一种叫做 DLS 的跨平台设计语言。每一个组件都有 Android,iOS,React Native 和 web 的版本。用一套容易的设计语言便于开发跨平台特性,因为这意味着设计,组件名称和界面在平台间是一致的。然而我们仍然需要在适当的情况下做出适合平台的决策。例如,我们在 Android 上使用原生的 Toolbar ,在 iOS 上使用 UINavigationBar ,且选择在 Android 上隐藏 disclosure indicators ,因为他们没有遵守 Android 平台的设计准则。

我们选择重写组件,来代替包装原生的组件,因为为每个平台单独创建平台适用的 API 更加可靠,且能够减少不知道如何在 React Native 上测试变动的 Android 和 iOS 工程师的维护开销。然而,这确实导致了同一组件在原生和 React Native 不同版本间不同步,而产生的平台间的碎片化。

React

React 成为最受欢迎的 web 框架是有原因的。它很简单同时功能又很强大,适用于大型代码库。其中我们比较喜欢的有:

  • 组件:React 的组件强制将关注点与定义良好的属性和状态分离开来。这为 React 的扩展性做了主要贡献。
  • 简明的生命周期:众所周知,Android(程度相对轻微)和 iOS 生命周期很复杂。功能灵活 React Native 组件从根本上解决了这个问题,使学习 React Native 比学习 Android 或 iOS 更加简单。
  • 声明式:React 的生命特性有利于 UI 层与底层状态保持同步。

迭代速度

在使用 React Native 开发的过程中,我们能够可靠的使用热重载来一两秒内在 Android 和 iOS 上测试变动。尽管构建性能是原生应用的首要任务,但它从来没有接近于我们用 React Native 所达到的迭代速度。最好的情况下,原生编译要15秒,对于完整的构建,最长可达20分钟。

投资基础设施

我们在原生基础设施上做了大范围的整合。所有的核心部分,例如网络,国际化,实验,共享的元素转换,设备信息,账户信息,以及很多其他的都被包进了一个 React Native API 中。这些桥梁中有些事十分复杂的,因为我们想要将现有的 Android 和 iOS 的 APIs 包装成对于 React 来说具有一致性和权威性的东西。虽然说通过快速迭代和新的基础设施开发来保持桥梁的更新,是一个持续追赶的过程,但是基础设施团队的投入能够使产品工作变得更加容易。

如果不对基础设施做大量的投入,React Native 会出现表现欠佳的开发者和不尽如人意的用户体验。因此,我们不应该认为 React Native 可以无需大量持续的投资,就被简单的附加进现有的 app 中。

性能

人们对 React Native 最大的担忧之一来自于它的性能。然而,在实践中,这并不算是问题。我们绝大的多数的 React Native 项目的界面表现像我们的原生应用一样流畅。性能通常被认为是一个单一维度。经常会看到移动端工程师看着 JS,心里想到的是“比 Java 慢”。然而在很多情况下,把业务逻辑和布局从主线程移出去,实际上提升了渲染的性能。

当出现性能问题时,通常时由于过度渲染引起的,可以通过,使用shouldComponentUpdateremoveClippedSubviews,以及更好的使用 Redux 来有效的缓解。

然而,初始化和首次渲染的时间(如下所述)使得 React Native 在启动界面,深层链接方面表现差强人意,以及会增加界面跳转时的传输时间间隔。此外,丢帧的界面很难调试,因为 Yoga 在 React Native 组件和原生视图间转化。

Redux

我们使用 Redux 来做状态管理,能够有效的阻止 UI 和状态不同步的情况,且能方便的实现不用页面间的数据共享。然而 Redux 因为它的模板而臭名昭著,还有着陡峭的学习曲线。虽然我们为一些通用模板提供了生成器,但在使用 React Native 的过程中,模板仍然是最具挑战之一和混淆的源头。值得指出的是,这些挑战并非是 React Native 特有的。

原生支持

由于 React Native 中的所有内容都可以由原生代码来进行桥接,所以我们最终能够构建一开始不确定的东西,比如:

  1. 共享元素的转换 : 我们建立了 组件,这个组件由 Android 和 iOS 的原生共享元素代码来支持。甚至能够在原生和 React Native 之间工作。
  2. Lottie : 通过包装 Android 和 iOS 上现有的库,我们可以让 Lottie 在 React Native 上工作。
  3. 原生网络栈 : React Native 在两个平台上均使用已有的原生网络栈和缓存。
  4. 其他的核心设施 :像网络模块一样,我们包装了其余的现有原生基础设施,例如国际化,实验等,以便能够在 React Native 上无缝运行。

静态分析

在 web 开发中,我们有着悠久的使用 eslint 的历史,本可以利用这一点,但我们又是 Airbnb 内部第一个推行 prettier 的平台。使用 prettier 可以有效的减少处理 PR 时的 nits( = nit-pick = 吹毛求疵的的小变动 = Small change that may not be very important, but is technically correct )和自行车棚效应。我们的 web 基础框架团队正在积极的调研 prettier。

我们还使用 analytics(分析)来计算渲染时间和性能,用来指出性能问题中,对哪些界面进行优先排查。

由于 React Native 比我们的 web 框架更加新,且体量更小,十分适合用来做新想法的试验台。很多我们使用 React Native 创立起来的工具和想法,现在都在用 web 来发展壮大。

动效

多亏了 React Native 的 Animated 库,我们得以实现无抖动的动效,甚至能够实现例如滚动视差(scrolling parallax)这样的,交互驱动的动效。

弹性盒子(flexbox)

React Native 使用 Yoga 处理布局,Yoga 是一个跨平台的 C 语言库,用来处理经由 flexbox API 生成的布局计算。前段时间,我们触到了 Yoga 的盲区,比如缺少宽高比,不过已经在后去的迭代中更新了。另外,例如像 flexbox froggy 这样有趣的教程,让入门更加享受。

跟 Web 的协作

在 React Native 后期的探索中,我们开始一次性构建 web,iOS 和 Android 三个平台的应用。鉴于 web 端也使用了 Redux,我们发现大量的代码是可以同时在 web 和原生平台上使用的,不需要更改。

缺点

React Native 不够成熟

React Native 跟 Android 和 iOS 平台相比,依旧不够成熟。React Native 更加年轻,雄心勃勃,且迭代更新很迅速。
虽然说 React Native 在大多数情况下表现的都很好,依旧有部分情况下,React Native 的不成熟就体现出来了,使得本来在原生上实现起来微不足道的东西变得很困难。不幸的是,这类的情况很难预测,要花费上几小时到几天不等的时间来解决。

维护 React Native 的分支

由于 React Native 的不成熟,有很多次我们需要给 React Native 的源代码打补丁。除了参与 React Native 的贡献外,我们还必须维护一个分支,用来快速的合并变动和突破我们的版本。在两年中,我们在 React Native 上提交了大概50个 commits。这使更新 React Native 的过程太痛苦了。

JavaScript 工具

JavaScript 是弱类型语言。类型安全性的缺乏不仅让扩展变得困难,同时也成为了有兴趣学习 React Native 但习惯了强类型语言的移动工程师讨论的焦点。我们调研了流程,但令人费解的错误提示导致了很糟糕的开发体验。我们还调研了 TypeScript,但是发现把它集成进我们现有的框架中非常困难,例如 babelmetro bundler。不过,我们持续的在 web 端积极调研 TypeScript。

重构

弱类型语言 JavaScript 带来的一个副作用是重构很困难还容易出错。重命名属性,尤其是有着通用名字的属性,比如 onClick 这种,或者是通过多个组件传递的属性,重构起来就是噩梦。更糟糕的是,重构是在在生产环境中断,而不是在编译时,这就很难去增加属性的静态分析。

JavaScript 内核的不一致

React Native 比较微妙和棘手的方面在于它是在 JavaScript 内核的环境中执行的。以下是我们遇到的情况的总结结果:

  • iOS 环境中运行会使用平台自己的 JavaScript 内核。这意味着 iOS 一如既往的不会给我们添麻烦。
  • Android 并不会使用它自己的 JavaScript 内核,所以 React Native 捆绑了自己的。然而默认情况下你拿到的是古老的版本,因此,我们需要自己动手捆绑一个新的
  • 调试的时候,React Native 附带了一个 Chrome 的开发者工具。这点很棒,Chrome 开发者工具是一个非常强大的调试工具。但是一旦接入了这个调试工具,所有的 JavaScript 代码就跑在了 Chrome 的 V8 引擎上了。这在 99.99%的情况下是 OK 的,但在我们遇到了这么一种情况,toLocalString 在 iOS 环境下很正常,但是在 Android 上只有调试状态下才工作。这说明 Android 的 JavaScript 内核不包含 toLocalString,它就默默的失败了,除非你在调试,但这种情况下它使用的又是 V8 引擎。如果不知道这样的技术细节,带给工程师们的会是几天痛苦的调试过程。

React Native 开源库

学习一个平台是困难且耗时的。大多数的人只熟悉1-2个平台。React Native 库包含对原生的桥接,例如地图,视频等,这需要三个平台等量的知识才能自如的运用。我们发现大多数的 React Native 开源项目是由只有一个或两个平台经验的人编写的。这会导致在 Android 和 iOS 平台上出现表现不一致和意外的 bug。

在 Android 平台,很多 React Native 库依旧要求你对 node_modules 使用相对路径,而不是发布与社区期待不一致的 maven artifacts。

并行的基础架构和产品功能性工作

我们积攒了很多年的 Android 和 iOS 平台的原生基础设施。然而在 React Native 方面,是从零开始为已有的基础设施编写或者创造桥接。这意味着发生过很多次产品工程师需要一些还不存在的功能的情况。基于此,工程师们要么需要在他们不熟悉的平台上干活,在他们的项目之外进行构建,要么被卡住等到有人创建这个功能。

崩溃监控

在 Android 和 iOS 平台上,我们使用 Bugsnag来上报崩溃。虽然能让 Bugsnag 在两个平台上都正常工作,但它可靠性较低,而且跟在我们其他的平台上相比,工作量也要更大。因为 React Native 在行业内是较新的框架且相对比较少见,我们不得不建立大量的例如 ‘在内部上传 source maps’ 这样的基础设施,而且还要与 Bugsnag 合作来做到一些类似 ’过滤出仅在 React Native 中发生的崩溃‘ 这样的事情。

鉴于围绕 React Native 的定制基础设施的数量,我们偶尔遇到一些严重的问题,但没有上报崩溃或者 source map 没有上传。

最后,如果问题跨域 React Native 和原生两部分的代码,调试 React Native 崩溃就非常有挑战性了,因为堆栈跟踪不会在 React Native 和原生之间跳转。

原生桥接

React Native 自带桥接的 API,以便在原生和 React Native 之间通信。虽说它能够正常的工作,但是编写起来还是极其麻烦的。首先,它需要三种开发环境才能正确的建立好。我们还遇到了些来自于 JavaScript,出乎意料的类型问题。举个栗子,整数常常被字符串包裹,这个问题直到它要通过桥接时才被发现。更加糟糕的是,有时 iOS 会默默地失败,但 Android 会崩溃。我们开始研究用 TypeScript 的定义来自动生成桥接代码直到2017年底,但是量太少也太晚了。

初始化时间

在 React Native 能开始初次渲染之前,必须初始化它的进行时。不幸的是,对我们的 app 的体积来说,需要几秒钟,这还是在高端机上。这让使用 React Native 来启动页面几乎不太可能。我们在 app 启动时就初始化React Native,来尽可能压缩它的首次渲染时间。

初始渲染时间

与原生界面不同的是,在有足够的信息首次渲染界面前,渲染 React Native 至少需要一个完整的主线程 -> js -> yoga 布局线程 -> 主线程的闭环。我们可以看到在 iOS 上平均初始 90% 渲染要 280 毫秒,Android 上要 440 毫秒。在安卓上,我们使用通常用于共享元素转换的 postponeEnterTransition API 来实现延迟显示界面直到渲染完成。在 iOS 上我们遇到了从 React Native 快速设置导航栏配置不够快速的问题。因此,我们对所有的 React Native 界面过渡加上了50毫秒的仿真延迟,以防止配置加载后导航栏出现闪烁。

App 的大小

React Native 对 app 大小也有这不可忽视的影响。在 Android 上,React Native 的总大小(Java + JS + 原生库,比如Yoga + Javascript 运行时)达到了 8MB 每个 ABI。在一个 APK 中使用 x86 和 arm(仅 32 位),体积达到了接近 12MB。

64位

因为这个问题,我们仍然不能在 Android 上安装一个 64 位的 APK。

手势

我们避免在涉及到有复杂手势操作的界面上使用 React Native,因为 Android 和 iOS 的触摸子系统完全不一样,整理出一套统一的 API 对于整个 React Native 社区都是一个挑战。但这项工作还在持续进行,react-native-gesture-handler 刚刚发布了1.0版本。

过长的 list

React Native 在这方面取得了一些进展,比如 FlatList 这样的库。但成熟度和灵活性还是远不及 Android 上的 RecyclerView 和 iOS 上的 UICollectionView。由于线程问题,很多限制很难突破。适配数据无法同步访问,会导致视图闪烁,因为在快速滚动的时候进行的是异步渲染。文本也无法做到同步测算,所以 iOS 无法使用预先计算的 cell 高度来做某些优化。

升级 React Native

虽然大多数的 React Native 升级都很微不足道,但还是有一些让人非常痛苦。尤其是 React Native 0.43(2017年4月)至 React Native 0.49(2017年10月)版本几乎无法使用,因为其中使用了 React 16 alpha 和 beta。这是个很严重的问题,因为大多数专为 web 设计的 React 库不支持以前的 React 版本。争论此次升级适当依赖关系的过程,对2017年中其他的 React Native 基础架构工作造成了重大损害。

辅助功能

2017年,我们进行了一次辅助功能的彻底检修,在这其中投入了大量的精力,以保证残障人士可以使用 Airbnb 预定到满足他们使用需要的房源。但是 React Native 的辅助功能 API 有着许多漏洞。为了满足最小可接受程度的辅助功能条,我们必须维护一个自己的用于合并修复的 React Native 分支。对于这些情况,Android 或 iOS 上的一个一行的修复,需要数天时间才能确定如何将其添加到 React Native,然后 cherry pick 它,在 React Native Core 上提交 issue,然后接下来的几周跟踪这个问题。

麻烦的崩溃

我们还不得不处理一些很难解决的奇葩的崩溃。举个栗子,我们最近在这个注释下遇到了这个崩溃,且无法在任何设备上复现,即便是那些跟一直崩溃的设备具有相同硬件和软件的也是如此。

Android 上的 SavedInstanceState 跨进程

Android 会经常去清理后台进程,但给了他们一个把状态同步保存进 bundle 的机会 。但在 React Native 上,所有的状态都在 js 线程中访问,无法同步进行。即便状况不是这样,存储状态的 redux 也与这种方式不兼容,因为它包含的是可序列化数据和不可序列化数据的混合,而且还包括了超出适用于 savedInstanceState 中的数据,这会导致在生产环境中的崩溃。


这是系列博客文章的第二部分,重点讲述了我们使用 React Native 的经验,以及 Airbnb 在移动端接下来要做的事情。

  • 第一弹:在 Airbnb 使用 React Native (译文)
  • 第二弹:技术
  • 第三弹:组建一个跨平台移动端团队 (译文)
  • 第四弹:在 React Native 方面做个决定 (译文)
  • 第五弹:在移动端接下来要做的事情 (翻译中…)

翻译:Rebecca Han
译自 Gabriel Peal(Airbnb 工程师) 发表在 Medium 上的文章。
原文链接: [https://medium.com/airbnb-engineering/react-native-at-airbnb-the-technology-dafd0b43838)